React + Redux + TypeScript 实践三:Redux 设计

本文将介绍如何把 Redux 中优秀的设计模式和 TypeScript 这门语言结合起来,全文会围绕 Redux store 设计、Redux reducer 设计、Redux action 的设计以及常用异步中间件(redux-thunk 和 redux-promise-middleware)的使用展开。

接着实践二讲,本文要实现的功能是,在页面加载完成之前,通过 Redux 请求 /user/detail 接口获取用户基本信息并把信息写入到对应的 store 中。

页面组件 User 的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 文件: client/pages/user/index.tsx
import {Spin, Input} from 'antd'
// User 组件
class User extends React.Component {
render() {
...
return (
<Spin spinning={ui.loading}>
<strong>用户姓名:</strong>
<Input value={user.name} />
<strong>用户年龄:</strong>
<Input value={user.age} />
<strong>所属部门:</strong>
<Input value={user.department} />
</Spin>
)
}
}
  • Redux store 设计

基于 Ducks 架构,我们会根据每个页面状态划分出一个独立的 state,将这些页面的 state 组合在一起形成一个完整的 store 状态树。以 User 页面为例,根据上述 User 页面组件的代码可以看出 User 组件依赖以下四个属性:

name type description
loading boolean 页面是否处于加载
name string 用户姓名
age string 用户年龄
department string 用户所属部门

其中 loading 属于页面 UI 状态,name、age 和 department 属于请求 /user/detail 接口获取到的一部分数据,我们可以简单地把没有业务属性的 UI 状态划分为一类数据,其他数据再按照各自的业务属性划分成不同类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 文件: client/types/store.d.ts
// User 组件状态接口
interface IUserState {
ui: {
loading: boolean
}
user: IUser | {}
}
// store 状态树接口
interface IStoreTree {
user: IUserState
}
// 状态树任意属性类型
type IStoreNode = IStoreTree[keyof IStoreTree]
  • Redux action 设计

先看 Redux 本身对于 action 的接口定义:

1
2
3
4
5
6
7
8
9
10
// 源码: https://github.com/reduxjs/redux/blob/master/index.d.ts
// action 接口
interface Action<T = any> {
type: T
}
// 任意 action 接口
interface AnyAction extends Action {
[extraProps: string]: any
}

Redux action 的原始接口定义很简单,必须要有一个任意类型的 type 属性,而其他属性可以让用户随意定制,在实际应用中,这种接口定义不够严格和规范,会出现各式各样的 action,因此,我们将采用 Flux action 标准来规范 Redux action 的使用。

Flux action 的属性有:

name type required description
type string √ action 标识
payload any action 数据载荷
error any 标志 action 是否是异常的,如果为真,则 payload 应该为 Error 对象
meta any action 中不属于 payload 的额外数据信息

根据 Flux action 的属性可以定义 FluxAction 接口:

1
2
3
4
5
6
7
8
9
10
// 文件: client/types/store.d.ts
import {Action} from 'redux'
// flux action 接口
interface FluxAction<P = any, M = any> extends Action {
type: string
payload?: P
error?: any
meta?: M
}

在 Redux 中,action 会由 action creator 函数生成,Redux 对于 action creator 的原始接口定义为:

1
2
3
4
5
// 源码: https://github.com/reduxjs/redux/blob/master/index.d.ts
// action creator 接口
interface ActionCreator<A>{
(...args: any[]): A
}

基于 ActionCreator,我们可以构造出专门生成 FluxAction 类型 action 的 action creator:

1
2
3
4
// 文件: client/types/store.d.ts
import {ActionCreator} from 'redux'
// flux action creator 接口
type FluxActionCreator<P=any, M=any> = ActionCreator<FluxAction<P,M>>

假设我们现在需要切换 User 组件上的 loading 状态,要求通过 action creator 发出对应的 FluxAction 类型的 action,那么我们可以这么定义这个 action creator:

1
2
3
4
5
6
// 文件: client/pages/user/user.ts
// 切换 loading 状态
const toggleLoading: FluxActionCreator<boolean> = (loading: boolean) => ({
type: 'TOGGLE_LOADING',
payload: loading
})

当然,FluxActionCreator 类型定义稍显复杂,而复杂的类型定义会带来一定的理解成本,因此,我们也可以将 toggleLoading 简单定义为:

1
2
3
4
5
// 文件: client/pages/user/user.ts
const toggleLoading = (loading: boolean):FluxAction<boolean> => ({
type: 'TOGGLE_LOADING',
payload: loading
})

再比如修改 IUserState.user:

1
2
3
4
5
// 文件: client/pages/user/user.ts
const updateUser = (user: IUser):FluxAction<IUser> => ({
type: 'UPDATE_USER',
payload: user
})
  • Redux 异步 action 设计

Redux 本身不支持异步 action,需要引入额外的中间件来支持,这里举两个常见的异步中间件 redux-thunk 和 redux-promise-middleware,我们将分别讲解如何通过这两个中间件从 /user/detail 接口获取用户基本信息。

I. redux-thunk

通常情况下,action creator 函数会返回一个对象,而在引入 redux-thunk 中间件后,action creator 会返回一个函数,该函数参数如下表:

name type description
dispatch ThunkDispatch action creator 触发函数
getState function(): any 获取整个 store tree 函数
extraArgument any 额外的参数
1
2
3
4
5
6
7
8
9
10
11
12
13
// 源码: https://github.com/reduxjs/redux-thunk/blob/master/index.d.ts
import { Middleware, Action, AnyAction } from "redux";
export interface ThunkDispatch<S, E, A extends Action> {
<R>(thunkAction: ThunkAction<R, S, E, A>): R;
<T extends A>(action: T): T;
}
export type ThunkAction<R, S, E, A extends Action> = (
dispatch: ThunkDispatch<S, E, A>,
getState: () => S,
extraArgument: E
) => R;

基于以上源码,我们可以构造出 FluxAction 相关的 ThunkDispatch 和 ThunkAction

1
2
3
4
5
6
7
8
// 文件: client/types/store.d.ts
import {ThunkDispatch, ThunkAction} from 'redux-thunk'
// FluxThunkDispatch 接口
type FluxThunkDispatch<S, P, M=any, E=any> = ThunkDispatch<S, E, FluxAction<P, M>>
// FluxThunkAction 接口
type FluxThunkAction<S, P, M=any, R=any, E=any> = ThunkAction<R, S, E, FluxAction<P, M>>

在 FluxThunkDispatch 和 FluxThunkAction 中,我们调整了泛型类型的定义顺序,并对 M、R、E 提供了默认的类型 any,这是因为在大多数情况下,我们更关心代表 store tree 类型的 S 和代表 action payload 类型的 P。来看个使用 FluxThunkAction 的例子:

1
2
const f: FluxThunkAction<IStoreTree, boolean> =
(dispatch: FluxThunkDispatch<IStoreTree, boolean>, getState: () => IStoreTree, extraArgument: any) => {}

通常情况下,一个应用只会配一个 store tree,因此 FluxThunkAction 和 FluxThunkDispatch 可以简化为:

1
2
3
4
5
6
7
8
// 文件: client/types/store.d.ts
// 以 IStoreTree 为 S 的 FluxThunkDispatch 接口
type StoreFTD<P, M=any, E=any> =
FluxThunkDispatch<IStoreTree, P, M, E>
// 以 IStoreTree 为 S 的 FluxThunkAction 接口
type StoreFTA<P, M=any, R=any, E=any> =
FluxThunkAction<IStoreTree, P, M, R, E>

上述例子就可以简化为:

1
2
const f: StoreFTA<boolean> =
(dispatch: StoreFTD<boolean>, getState: () => IStoreTree, extraArgument: any) => {}

现在再看从 /user/detail 接口获取用户信息功能,要求服务端在响应过程中,前端要展示 loading 状态,等到服务端返回后前端再取消 loading 状态,通过 redux-thunk 可以这么实现 action creator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 文件: client/pages/user/user.ts
import {getUserDetail as _getUserDetail} from 'services'
// 获取用户详情
const getUserDetail = (name: string):StoreFTA<boolean | IUser | {}> => (dispatch: StoreFTD<boolean | IUser | {}>) => {
// 显示 loading
dispatch(toggleLoading(true))
// 发起请求
_getUserDetail({name})
.then((data: IUser) => {
// 请求成功,更改 user 并取消 loading 状态
dispatch(updateUser(data))
dispatch(toggleLoading(false))
})
.catch((err: Error) => {
console.error(err.message)
// 请求失败,更改 user 为空并取消 loading 状态
dispatch(updateUser({}))
dispatch(toggleLoading(false))
})
}

II. redux-promise-middleware

redux-thunk 在 action creator 内部处理不同的异步状态,而 redux-promise-middleware 则把这些处理逻辑移至 reducer 中处理,action creator 应该尽量保持代码简洁,减少衍生计算逻辑,从这点看,redux-promise-middleware 要优于 redux-thunk 并且 redux-promise-middleware 对于 Promise 异步状态划分的更为清晰,代码可读性更强。

redux-promise-middleware 要求 action payload 类型为 Promise 类型或是包含以下两个属性的对象

name type description
promise Promise Promise 对象
data any 乐观更新的数据
1
2
3
4
5
6
// 文件: client/types/store.d.ts
// Promise payload 类型
type PromisePayload<P, T = any> = Promise<P> | {
promise: Promise<P>
data: T
}

从 /user/detail 接口获取用户信息的 action creator 接口可以写成:

1
2
3
4
5
6
// 文件: client/pages/user/user.ts
// 获取用户详情
const getUserDetail = (name: string): FluxAction<PromisePayload<IGetUserRes>> => ({
type: 'GET_USER_DETAIL',
payload: _getUserDetail({name})
})

相比于 redux-thunk,redux-promise-middleware 的 action creator 代码要简洁很多。

  • Redux reducer 设计

设计好 Redux store 后,还需要定义对应的 Redux reducer 来接收 Redux action 并对 Redux store 进行衍生计算。

先看 Redux 对 Reducer 类型的定义:

1
2
3
4
5
6
7
8
9
10
11
// 源码: https://github.com/reduxjs/redux/blob/master/index.d.ts
// reducer 类型
export type Reducer<S = any, A extends Action = AnyAction> = (
state: S | undefined,
action: A
) => S
// reducer 函数集合类型
export type ReducersMapObject<S = any, A extends Action = Action> = {
[K in keyof S]: Reducer<S[K], A>
}

结合 FluxAction 和 IStoreTree 可以定义我们自己想要的 Reducer 和 ReducersMapObject:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 文件: client/types/store.d.ts
import {Reducer, ReducersMapObject} from 'redux'
// 结合 FluxAction 的 Reducer
type FluxReducer<S = any, P = any,M = any> = Reducer<S, FluxAction<P, M>>
// 结合 FluxAction 的 ReducersMapObject
type FluxReducerMap<S = any, P = any, M = any> = ReducersMapObject<S, FluxAction<P,M>>
// 结合 IStoreTree 的 FluxReducer
type StoreFR<P = any, M = any> = FluxReducer<IStoreTree, P, M>
// 结合 IStoreTree 的 FluxReducerMap
type StoreFRM<P = any, M = any> = FluxReducerMap<IStoreTree, P, M>

当涉及到组件状态时候,不可变数据总是绕不开的话题,社区里常用的不可变数据函数库有 immutable 和 immer 等等,由于 immutable 对于数据结构处理较为繁琐,需要在它提供的对象和原始 javascript 对象间来回切换,故打算采用更为轻量的 immer 库,为了让开发者对不可变数据无感知,需要对每个 reducer 做些额外处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 文件: client/store/create-immutable-reducer.ts
import produce from 'immer'
import {ReducersMapObject} from 'redux'
// 通过 immer 构建不可变 reducer
function createImReducer(reducer: StoreFR): StoreFR {
return (state: IStoreNode, action: FluxAction): IStoreNode =>
produce(state, (draftState: IStoreNode):IStoreNode => reducer(draftState, action))
}
// 构建不可变 reducer 集合
export default (reducers: StoreFRM) => {
const imReducers: StoreFRM = {}
Object.keys(reducers).forEach(
(key: string) => {
imReducers[key] = createImReducer(reducers[key])
})
return imReducers
}

构建好不可变状态后,可以开始实现 user reducer 的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 文件: client/pages/user/user.ts
// 初始状态
const initState: IUserState = {
ui: {
loading: false
},
user: {}
}
// user reducer
const reducer:StoreFR<IUserState> = (state: IUserState, action: FluxAction) => {
const {type, payload} = action
switch(type) {
// 切换 loading 状态
case 'TOGGLE_LOADING':
state.ui.loading = payload
return state
// 修改用户信息
case 'UPDATE_USER':
state.user = payload
return state
// 获取用户信息
case 'GET_USER_DETAIL_PENDING':
state.ui.loading = true
return state
case 'GET_USER_DETAIL_REJECTED':
state.ui.loading = false
state.user = {}
return state
case 'GET_USER_DETAIL_FULFILLED':
state.ui.loading = false
state.user = payload
return state
}
}

上述代码中 reducer 函数是标准的 Redux reducer,这种方式存在函数过长、功能划分不够细致、 action payload 类型不确定等弊端,可以尝试使用 type-to-reducer 库对其进行简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 文件: client/pages/user/user.ts
import TypeToReducer from 'type-to-reducer'
// 初始状态
const initState: IUserState = {
ui: {
loading: false
},
user: {}
}
// user reducer
const reducer = TypeToReducer(
{
TOGGLE_LOADING: (state: IUserState, action: FluxAction<boolean>): IUserState => {
state.ui.loading = action.payload
return state
},
UPDATE_USER: (state: IUserState, action: FluxAction<IUser>): IUserState => {
state.user = user
return state
},
GET_USER_DETAIL: {
PENDING: (state: IUserState): IUserState => {
state.ui.loading = true
return state
},
REJECTED: (state: IUserState): IUserState => {
state.ui.loading = false
state.user = {}
return state
},
FULFILLED: (state: IUserState, action: FluxAction<IUser>): IUserState => {
state.ui.loading = false
state.user = action.payload
return state
}
}
},
initState
)

至此还剩最后三个步骤:

  1. 把不可变 reducer 的创建和普通 reducer 结合起来

    1
    2
    3
    4
    5
    6
    7
    8
    // 文件: client/store/root-reducer.ts
    // reducer 集合
    import createImReducers from './create-immutable-reducers'
    import user from '../pages/user/user'
    export default createImReducers({
    user
    })
  2. 创建 store 并引入中间件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 文件: client/store/create-store.ts
    import Thunk from 'redux-thunk'
    import promiseMiddleware from 'redux-promise-middleware'
    import { createStore, combineReducers, applyMiddleware } from 'redux'
    import reducers from './root-reducer'
    export default function(initialState:IStoreTree):any {
    return createStore(
    combineReducers(reducers),
    initialState,
    applyMiddleware(promiseMiddleware(), Thunk)
    )
    }
  3. User 页面组件连接 redux

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    // 文件: client/pages/user/index.tsx
    import {connect} from 'react-redux'
    import {bindActionCreators, Dispatch} from 'redux'
    import {Spin, Input} from 'antd'
    import {getUserDetail} from './user'
    // User 组件属性接口
    interface IProps extends IUserState {
    actions: any
    }
    // User 组件
    class User extends React.Component<IProps> {
    componentDidMount() {
    this.props.actions.getUserDetail('xxxx')
    }
    render() {
    const {ui, user} = this.props
    return (
    <Spin spinning={ui.loading}>
    <strong>用户姓名:</strong>
    <Input value={user.name} />
    <strong>用户年龄:</strong>
    <Input value={user.age} />
    <strong>所属部门:</strong>
    <Input value={user.department} />
    </Spin>
    )
    }
    }
    export default connect(
    (store: IStoreTree): IUserState => store.user,
    (dispatch: Dispatch<FluxAction>) => ({
    actions: bindActionCreators({getUserDetail}, dispatch)
    })
    )